Bug: feat: add caching for roles



Currently in authorization, we look at the resource and the role in the JWT to check whether that particular call is valid or not.

Upon the introduction of custom roles, this has increased db calls in the authorization phase as we need to get the permissions associated to a particular role. This will have unnecessary load on db and hits the performance of every api call.

To fix this, redis needs to be used to cache the permissions for a custom role.
diff --git a/crates/api_models/src/user.rs b/crates/api_models/src/user.rs
index 8f77f72aad5..1641c0ae536 100644
--- a/crates/api_models/src/user.rs
+++ b/crates/api_models/src/user.rs
@@ -128,8 +128,6 @@ pub struct SwitchMerchantIdRequest {
     pub merchant_id: String,
 }
 
-pub type SwitchMerchantResponse = DashboardEntryResponse;
-
 #[derive(serde::Deserialize, Debug, serde::Serialize)]
 pub struct CreateInternalUserRequest {
     pub name: Secret<String>,
diff --git a/crates/api_models/src/user_role.rs b/crates/api_models/src/user_role.rs
index 80d3861969a..411bf823a22 100644
--- a/crates/api_models/src/user_role.rs
+++ b/crates/api_models/src/user_role.rs
@@ -1,8 +1,6 @@
 use common_enums::PermissionGroup;
 use common_utils::pii;
 
-use crate::user::DashboardEntryResponse;
-
 pub mod role;
 
 #[derive(Debug, serde::Serialize)]
@@ -99,8 +97,6 @@ pub struct AcceptInvitationRequest {
     pub need_dashboard_entry_response: Option<bool>,
 }
 
-pub type AcceptInvitationResponse = DashboardEntryResponse;
-
 #[derive(Debug, serde::Deserialize, serde::Serialize)]
 pub struct DeleteUserRoleRequest {
     pub email: pii::Email,
diff --git a/crates/router/src/consts.rs b/crates/router/src/consts.rs
index 72f160990e5..87a16b73a8b 100644
--- a/crates/router/src/consts.rs
+++ b/crates/router/src/consts.rs
@@ -78,6 +78,8 @@ pub const EMAIL_TOKEN_TIME_IN_SECS: u64 = 60 * 60 * 24; // 1 day
 #[cfg(feature = "email")]
 pub const EMAIL_TOKEN_BLACKLIST_PREFIX: &str = "BET_";
 
+pub const ROLE_CACHE_PREFIX: &str = "CR_";
+
 #[cfg(feature = "olap")]
 pub const VERIFY_CONNECTOR_ID_PREFIX: &str = "conn_verify";
 #[cfg(feature = "olap")]
diff --git a/crates/router/src/core/user.rs b/crates/router/src/core/user.rs
index b8b8cbd24c7..fceee528057 100644
--- a/crates/router/src/core/user.rs
+++ b/crates/router/src/core/user.rs
@@ -94,6 +94,7 @@ pub async fn signup(
         )
         .await?;
     let token = utils::user::generate_jwt_auth_token(&state, &user_from_db, &user_role).await?;
+    utils::user_role::set_role_permissions_in_cache_by_user_role(&state, &user_role).await;
 
     Ok(ApplicationResponse::Json(
         utils::user::get_dashboard_entry_response(&state, user_from_db, user_role, token)?,
@@ -121,6 +122,7 @@ pub async fn signin_without_invite_checks(
 
     let user_role = user_from_db.get_role_from_db(state.clone()).await?;
     let token = utils::user::generate_jwt_auth_token(&state, &user_from_db, &user_role).await?;
+    utils::user_role::set_role_permissions_in_cache_by_user_role(&state, &user_role).await;
 
     Ok(ApplicationResponse::Json(
         utils::user::get_dashboard_entry_response(&state, user_from_db, user_role, token)?,
@@ -966,6 +968,8 @@ pub async fn accept_invite_from_email(
 
     let token =
         utils::user::generate_jwt_auth_token(&state, &user_from_db, &update_status_result).await?;
+    utils::user_role::set_role_permissions_in_cache_by_user_role(&state, &update_status_result)
+        .await;
 
     Ok(ApplicationResponse::Json(
         utils::user::get_dashboard_entry_response(
@@ -1044,7 +1048,7 @@ pub async fn switch_merchant_id(
     state: AppState,
     request: user_api::SwitchMerchantIdRequest,
     user_from_token: auth::UserFromToken,
-) -> UserResponse<user_api::SwitchMerchantResponse> {
+) -> UserResponse<user_api::DashboardEntryResponse> {
     if user_from_token.merchant_id == request.merchant_id {
         return Err(UserErrors::InvalidRoleOperationWithMessage(
             "User switching to same merchant id".to_string(),
@@ -1093,13 +1097,14 @@ pub async fn switch_merchant_id(
             .organization_id;
 
         let token = utils::user::generate_jwt_auth_token_with_custom_role_attributes(
-            state,
+            &state,
             &user,
             request.merchant_id.clone(),
-            org_id,
+            org_id.clone(),
             user_from_token.role_id.clone(),
         )
         .await?;
+
         (token, user_from_token.role_id)
     } else {
         let user_roles = state
@@ -1120,11 +1125,13 @@ pub async fn switch_merchant_id(
             .attach_printable("User doesn't have access to switch")?;
 
         let token = utils::user::generate_jwt_auth_token(&state, &user, user_role).await?;
+        utils::user_role::set_role_permissions_in_cache_by_user_role(&state, user_role).await;
+
         (token, user_role.role_id.clone())
     };
 
     Ok(ApplicationResponse::Json(
-        user_api::SwitchMerchantResponse {
+        user_api::DashboardEntryResponse {
             token,
             name: user.get_name(),
             email: user.get_email(),
@@ -1266,6 +1273,7 @@ pub async fn verify_email_without_invite_checks(
         .await
         .map_err(|e| logger::error!(?e));
     let token = utils::user::generate_jwt_auth_token(&state, &user_from_db, &user_role).await?;
+    utils::user_role::set_role_permissions_in_cache_by_user_role(&state, &user_role).await;
 
     Ok(ApplicationResponse::Json(
         utils::user::get_dashboard_entry_response(&state, user_from_db, user_role, token)?,
diff --git a/crates/router/src/core/user_role.rs b/crates/router/src/core/user_role.rs
index 9dca9b43d96..b81b980c07a 100644
--- a/crates/router/src/core/user_role.rs
+++ b/crates/router/src/core/user_role.rs
@@ -159,6 +159,7 @@ pub async fn transfer_org_ownership(
         .to_not_found_response(UserErrors::InvalidRoleOperation)?;
 
     let token = utils::user::generate_jwt_auth_token(&state, &user_from_db, &user_role).await?;
+    utils::user_role::set_role_permissions_in_cache_by_user_role(&state, &user_role).await;
 
     Ok(ApplicationResponse::Json(
         utils::user::get_dashboard_entry_response(&state, user_from_db, user_role, token)?,
@@ -169,7 +170,7 @@ pub async fn accept_invitation(
     state: AppState,
     user_token: auth::UserWithoutMerchantFromToken,
     req: user_role_api::AcceptInvitationRequest,
-) -> UserResponse<user_role_api::AcceptInvitationResponse> {
+) -> UserResponse<user_api::DashboardEntryResponse> {
     let user_role = futures::future::join_all(req.merchant_ids.iter().map(|merchant_id| async {
         state
             .store
@@ -202,6 +203,8 @@ pub async fn accept_invitation(
             .into();
 
         let token = utils::user::generate_jwt_auth_token(&state, &user_from_db, &user_role).await?;
+        utils::user_role::set_role_permissions_in_cache_by_user_role(&state, &user_role).await;
+
         return Ok(ApplicationResponse::Json(
             utils::user::get_dashboard_entry_response(&state, user_from_db, user_role, token)?,
         ));
diff --git a/crates/router/src/services/authentication/blacklist.rs b/crates/router/src/services/authentication/blacklist.rs
index 346e563ee32..2ee8302be63 100644
--- a/crates/router/src/services/authentication/blacklist.rs
+++ b/crates/router/src/services/authentication/blacklist.rs
@@ -17,6 +17,7 @@ use crate::{
 use crate::{
     core::errors::{UserErrors, UserResult},
     routes::AppState,
+    services::authorization as authz,
 };
 
 #[cfg(feature = "olap")]
@@ -47,10 +48,23 @@ pub async fn insert_role_in_blacklist(state: &AppState, role_id: &str) -> UserRe
             date_time::now_unix_timestamp(),
             expiry,
         )
+        .await
+        .change_context(UserErrors::InternalServerError)?;
+    invalidate_role_cache(state, role_id)
         .await
         .change_context(UserErrors::InternalServerError)
 }
 
+#[cfg(feature = "olap")]
+async fn invalidate_role_cache(state: &AppState, role_id: &str) -> RouterResult<()> {
+    let redis_conn = get_redis_connection(state)?;
+    redis_conn
+        .delete_key(authz::get_cache_key_from_role_id(role_id).as_str())
+        .await
+        .map(|_| ())
+        .change_context(ApiErrorResponse::InternalServerError)
+}
+
 pub async fn check_user_in_blacklist<A: AppStateInfo>(
     state: &A,
     user_id: &str,
diff --git a/crates/router/src/services/authorization.rs b/crates/router/src/services/authorization.rs
index 2a516cc82d5..dc6d82d4e92 100644
--- a/crates/router/src/services/authorization.rs
+++ b/crates/router/src/services/authorization.rs
@@ -1,7 +1,13 @@
+use std::sync::Arc;
+
 use common_enums::PermissionGroup;
+use error_stack::{IntoReport, ResultExt};
+use redis_interface::RedisConnectionPool;
+use router_env::logger;
 
 use super::authentication::AuthToken;
 use crate::{
+    consts,
     core::errors::{ApiErrorResponse, RouterResult, StorageErrorExt},
     routes::app::AppStateInfo,
 };
@@ -19,22 +25,95 @@ pub async fn get_permissions<A>(
 where
     A: AppStateInfo + Sync,
 {
-    if let Some(role_info) = roles::predefined_roles::PREDEFINED_ROLES.get(token.role_id.as_str()) {
-        Ok(get_permissions_from_groups(
-            role_info.get_permission_groups(),
-        ))
-    } else {
-        state
-            .store()
-            .find_role_by_role_id_in_merchant_scope(
-                &token.role_id,
-                &token.merchant_id,
-                &token.org_id,
-            )
-            .await
-            .map(|role| get_permissions_from_groups(&role.groups))
-            .to_not_found_response(ApiErrorResponse::InvalidJwtToken)
+    if let Some(permissions) = get_permissions_from_predefined_roles(&token.role_id) {
+        return Ok(permissions);
+    }
+
+    if let Ok(permissions) = get_permissions_from_cache(state, &token.role_id)
+        .await
+        .map_err(|e| logger::error!("Failed to get permissions from cache {}", e))
+    {
+        return Ok(permissions);
     }
+
+    let permissions =
+        get_permissions_from_db(state, &token.role_id, &token.merchant_id, &token.org_id).await?;
+
+    let token_expiry: i64 = token
+        .exp
+        .try_into()
+        .into_report()
+        .change_context(ApiErrorResponse::InternalServerError)?;
+    let cache_ttl = token_expiry - common_utils::date_time::now_unix_timestamp();
+
+    set_permissions_in_cache(state, &token.role_id, &permissions, cache_ttl)
+        .await
+        .map_err(|e| logger::error!("Failed to set permissions in cache {}", e))
+        .ok();
+    Ok(permissions)
+}
+
+async fn get_permissions_from_cache<A>(
+    state: &A,
+    role_id: &str,
+) -> RouterResult<Vec<permissions::Permission>>
+where
+    A: AppStateInfo + Sync,
+{
+    let redis_conn = get_redis_connection(state)?;
+
+    redis_conn
+        .get_and_deserialize_key(&get_cache_key_from_role_id(role_id), "Vec<Permission>")
+        .await
+        .change_context(ApiErrorResponse::InternalServerError)
+}
+
+pub fn get_cache_key_from_role_id(role_id: &str) -> String {
+    format!("{}{}", consts::ROLE_CACHE_PREFIX, role_id)
+}
+
+fn get_permissions_from_predefined_roles(role_id: &str) -> Option<Vec<permissions::Permission>> {
+    roles::predefined_roles::PREDEFINED_ROLES
+        .get(role_id)
+        .map(|role_info| get_permissions_from_groups(role_info.get_permission_groups()))
+}
+
+async fn get_permissions_from_db<A>(
+    state: &A,
+    role_id: &str,
+    merchant_id: &str,
+    org_id: &str,
+) -> RouterResult<Vec<permissions::Permission>>
+where
+    A: AppStateInfo + Sync,
+{
+    state
+        .store()
+        .find_role_by_role_id_in_merchant_scope(role_id, merchant_id, org_id)
+        .await
+        .map(|role| get_permissions_from_groups(&role.groups))
+        .to_not_found_response(ApiErrorResponse::InvalidJwtToken)
+}
+
+pub async fn set_permissions_in_cache<A>(
+    state: &A,
+    role_id: &str,
+    permissions: &Vec<permissions::Permission>,
+    expiry: i64,
+) -> RouterResult<()>
+where
+    A: AppStateInfo + Sync,
+{
+    let redis_conn = get_redis_connection(state)?;
+
+    redis_conn
+        .serialize_and_set_key_with_expiry(
+            &get_cache_key_from_role_id(role_id),
+            permissions,
+            expiry,
+        )
+        .await
+        .change_context(ApiErrorResponse::InternalServerError)
 }
 
 pub fn get_permissions_from_groups(groups: &[PermissionGroup]) -> Vec<permissions::Permission> {
@@ -62,3 +141,11 @@ pub fn check_authorization(
             .into(),
         )
 }
+
+fn get_redis_connection<A: AppStateInfo>(state: &A) -> RouterResult<Arc<RedisConnectionPool>> {
+    state
+        .store()
+        .get_redis_conn()
+        .change_context(ApiErrorResponse::InternalServerError)
+        .attach_printable("Failed to get redis connection")
+}
diff --git a/crates/router/src/services/authorization/info.rs b/crates/router/src/services/authorization/info.rs
index d6a20b7f8ed..7b82e84d64c 100644
--- a/crates/router/src/services/authorization/info.rs
+++ b/crates/router/src/services/authorization/info.rs
@@ -187,7 +187,7 @@ fn get_group_description(group: PermissionGroup) -> &'static str {
             "View Payments, Refunds, Mandates, Disputes and Customers"
         }
         PermissionGroup::OperationsManage => {
-            "Create,modify and delete Payments, Refunds, Mandates, Disputes and Customers"
+            "Create, modify and delete Payments, Refunds, Mandates, Disputes and Customers"
         }
         PermissionGroup::ConnectorsView => {
             "View connected Payment Processors, Payout Processors and Fraud & Risk Manager details"
diff --git a/crates/router/src/services/authorization/permissions.rs b/crates/router/src/services/authorization/permissions.rs
index 8d436618b32..edf9a40915c 100644
--- a/crates/router/src/services/authorization/permissions.rs
+++ b/crates/router/src/services/authorization/permissions.rs
@@ -1,6 +1,8 @@
 use strum::Display;
 
-#[derive(PartialEq, Display, Clone, Debug, Copy, Eq, Hash)]
+#[derive(
+    PartialEq, Display, Clone, Debug, Copy, Eq, Hash, serde::Deserialize, serde::Serialize,
+)]
 pub enum Permission {
     PaymentRead,
     PaymentWrite,
diff --git a/crates/router/src/types/domain/user.rs b/crates/router/src/types/domain/user.rs
index d6e878456e8..1850eb4b81a 100644
--- a/crates/router/src/types/domain/user.rs
+++ b/crates/router/src/types/domain/user.rs
@@ -861,8 +861,11 @@ impl SignInWithSingleRoleStrategy {
     async fn get_signin_response(self, state: &AppState) -> UserResult<user_api::SignInResponse> {
         let token =
             utils::user::generate_jwt_auth_token(state, &self.user, &self.user_role).await?;
+        utils::user_role::set_role_permissions_in_cache_by_user_role(state, &self.user_role).await;
+
         let dashboard_entry_response =
             utils::user::get_dashboard_entry_response(state, self.user, self.user_role, token)?;
+
         Ok(user_api::SignInResponse::DashboardEntry(
             dashboard_entry_response,
         ))
diff --git a/crates/router/src/utils/user.rs b/crates/router/src/utils/user.rs
index c3b795d1a57..cfab331f148 100644
--- a/crates/router/src/utils/user.rs
+++ b/crates/router/src/utils/user.rs
@@ -87,7 +87,7 @@ pub async fn generate_jwt_auth_token(
 }
 
 pub async fn generate_jwt_auth_token_with_custom_role_attributes(
-    state: AppState,
+    state: &AppState,
     user: &UserFromStorage,
     merchant_id: String,
     org_id: String,
diff --git a/crates/router/src/utils/user_role.rs b/crates/router/src/utils/user_role.rs
index bb83b82f2da..a0ac1400582 100644
--- a/crates/router/src/utils/user_role.rs
+++ b/crates/router/src/utils/user_role.rs
@@ -1,11 +1,14 @@
 use api_models::user_role as user_role_api;
 use common_enums::PermissionGroup;
-use error_stack::ResultExt;
+use diesel_models::user_role::UserRole;
+use error_stack::{IntoReport, ResultExt};
+use router_env::logger;
 
 use crate::{
+    consts,
     core::errors::{UserErrors, UserResult},
     routes::AppState,
-    services::authorization::{permissions::Permission, roles},
+    services::authorization::{self as authz, permissions::Permission, roles},
     types::domain,
 };
 
@@ -83,3 +86,47 @@ pub async fn validate_role_name(
 
     Ok(())
 }
+
+pub async fn set_role_permissions_in_cache_by_user_role(
+    state: &AppState,
+    user_role: &UserRole,
+) -> bool {
+    set_role_permissions_in_cache_if_required(
+        state,
+        user_role.role_id.as_str(),
+        user_role.merchant_id.as_str(),
+        user_role.org_id.as_str(),
+    )
+    .await
+    .map_err(|e| logger::error!("Error setting permissions in cache {:?}", e))
+    .is_ok()
+}
+
+pub async fn set_role_permissions_in_cache_if_required(
+    state: &AppState,
+    role_id: &str,
+    merchant_id: &str,
+    org_id: &str,
+) -> UserResult<()> {
+    if roles::predefined_roles::PREDEFINED_ROLES.contains_key(role_id) {
+        return Ok(());
+    }
+
+    let role_info = roles::RoleInfo::from_role_id(state, role_id, merchant_id, org_id)
+        .await
+        .change_context(UserErrors::InternalServerError)
+        .attach_printable("Error getting role_info from role_id")?;
+
+    authz::set_permissions_in_cache(
+        state,
+        role_id,
+        &role_info.get_permissions_set().into_iter().collect(),
+        consts::JWT_TOKEN_TIME_IN_SECS
+            .try_into()
+            .into_report()
+            .change_context(UserErrors::InternalServerError)?,
+    )
+    .await
+    .change_context(UserErrors::InternalServerError)
+    .attach_printable("Error setting permissions in redis")
+}

## Type of Change
<!-- Put an `x` in the boxes that apply -->

- [ ] Bugfix
- [ ] New feature
- [x] Enhancement
- [ ] Refactoring
- [ ] Dependency updates
- [ ] Documentation
- [ ] CI/CD

## Description
<!-- Describe your changes in detail -->
This PR will add caching for custom roles to reduce db calls and improve efficiency.

### Additional Changes

- [ ] This PR modifies the API contract
- [ ] This PR modifies the database schema
- [ ] This PR modifies application configuration/environment variables

<!--
Provide links to the files with corresponding changes.

Following are the paths where you can find config files:
1. `config`
2. `crates/router/src/configs`
3. `loadtest/config`
-->


## Motivation and Context
<!--
Why is this change required? What problem does it solve?
If it fixes an open issue, please link to the issue here.

If you don't have an issue, we'd recommend starting with one first so the PR
can focus on the implementation (unless it is an obvious bug or documentation fix
that will have little conversation).
-->
Closes #3944 

## How did you test it?
<!--
Did you write an integration/unit/API test to verify the code changes?
Or did you test this change manually (provide relevant screenshots)?
-->
Postman.
This cannot be tested in hosted environment as redis is not accessible directly but can be tested in local environment. So, writing the instructions for local testing here.

1. Signin with a user who has assigned a custom role (not a predefined role)
	```
	curl --location 'http://localhost:8080/user/signin' \
	--header 'Content-Type: application/json' \
	--data-raw '{
	    "email": "user email",
	    "password": "password"
	}'
	```

	You will get the following response
	```
	{
	    "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoiOWQyOTQzMDgtYjhjMC00ZTU1LTkwYjgtMjA5ZGZkNjFhZWE4IiwibWVyY2hhbnRfaWQiOiJtZXJjaGFudF8xNzA5MTI1MjczIiwicm9sZV9pZCI6InJvbGVfTmRDVW12WUIwalhCRmpwUW8ydEUiLCJleHAiOjE3MDk3MjkwNzcsIm9yZ19pZCI6Im9yZ181UEluV2RTN2FJc0FGa3g5dFUxWCJ9.Cf7ascQFk6-1USmhJlkk-B9gu0v6qQ-07SXCjZw6wEU",
	    "merchant_id": "merchant_1709125273",
	    "name": "user name",
	    "email": "user email",
	    "verification_days_left": null,
	    "user_role": "role_NdCUmvYB0jXBFjpQo2tE"
	}
	```
	Notice that `user_role` has a `role_id` which starts with `role_`. That means this user has been assigned a custom role.

2. Now run `redis_cli` command in the terminal.
3. In the prompt, execute `keys *` command. You should get the output that looks like this
	```
	1) "CR_role_NdCUmvYB0jXBFjpQo2tE"
	2) "123"
	3) "accounts"
	4) "234"
	```
	Notice that there is a key that starts with `CR_` and the remaining part matches with the `role_id` that we got in the signin response.

## Checklist
<!-- Put an `x` in the boxes that apply -->

- [x] I formatted the code `cargo +nightly fmt --all`
- [x] I addressed lints thrown by `cargo clippy`
- [x] I reviewed the submitted code
- [ ] I added unit tests for my changes where possible
- [ ] I added a [CHANGELOG](/CHANGELOG.md) entry if applicable
