From 071d5345b5801e124da18d679202b0a60033b2f5 Mon Sep 17 00:00:00 2001 From: Nishant Joshi Date: Tue, 9 Jul 2024 13:05:20 +0530 Subject: [PATCH] feat(decision): add support to register api keys to proxy (#5168) --- .../src/configs/secrets_transformers.rs | 1 + crates/router/src/configs/settings.rs | 6 + crates/router/src/core/admin.rs | 43 +++- crates/router/src/core/api_keys.rs | 60 ++++- crates/router/src/core/metrics.rs | 3 + crates/router/src/services/authentication.rs | 1 + .../src/services/authentication/decision.rs | 217 ++++++++++++++++++ 7 files changed, 329 insertions(+), 2 deletions(-) create mode 100644 crates/router/src/services/authentication/decision.rs diff --git a/crates/router/src/configs/secrets_transformers.rs b/crates/router/src/configs/secrets_transformers.rs index 50324d95064b..1ab2e519b7af 100644 --- a/crates/router/src/configs/secrets_transformers.rs +++ b/crates/router/src/configs/secrets_transformers.rs @@ -394,5 +394,6 @@ pub(crate) async fn fetch_raw_secrets( saved_payment_methods: conf.saved_payment_methods, multitenancy: conf.multitenancy, user_auth_methods, + decision: conf.decision, } } diff --git a/crates/router/src/configs/settings.rs b/crates/router/src/configs/settings.rs index 887299b79255..a29d7e1502d5 100644 --- a/crates/router/src/configs/settings.rs +++ b/crates/router/src/configs/settings.rs @@ -125,6 +125,7 @@ pub struct Settings { pub multitenancy: Multitenancy, pub saved_payment_methods: EligiblePaymentMethods, pub user_auth_methods: SecretStateContainer, + pub decision: Option, } #[derive(Debug, Deserialize, Clone, Default)] @@ -146,6 +147,11 @@ impl Multitenancy { } } +#[derive(Debug, Deserialize, Clone, Default)] +pub struct DecisionConfig { + pub base_url: String, +} + #[derive(Debug, Deserialize, Clone, Default)] #[serde(transparent)] pub struct TenantConfig(pub HashMap); diff --git a/crates/router/src/core/admin.rs b/crates/router/src/core/admin.rs index dabe11552942..b6664e7b54ae 100644 --- a/crates/router/src/core/admin.rs +++ b/crates/router/src/core/admin.rs @@ -28,7 +28,7 @@ use crate::{ }, db::StorageInterface, routes::{metrics, SessionState}, - services::{self, api as service_api}, + services::{self, api as service_api, authentication}, types::{ self, api, domain::{ @@ -281,6 +281,25 @@ pub async fn create_merchant_account( .await .to_duplicate_response(errors::ApiErrorResponse::DuplicateMerchantAccount)?; + if let Some(api_key) = merchant_account.publishable_key.as_ref() { + let state = state.clone(); + let api_key = api_key.clone(); + let merchant_id = merchant_account.merchant_id.clone(); + + authentication::decision::spawn_tracked_job( + async move { + authentication::decision::add_publishable_key( + &state, + api_key.into(), + merchant_id, + None, + ) + .await + }, + authentication::decision::ADD, + ); + } + db.insert_config(configs::ConfigNew { key: format!("{}_requires_cvv", merchant_account.merchant_id), config: "true".to_string(), @@ -650,6 +669,20 @@ pub async fn merchant_account_delete( ) -> RouterResponse { let mut is_deleted = false; let db = state.store.as_ref(); + + let merchant_key_store = db + .get_merchant_key_store_by_merchant_id( + &merchant_id, + &state.store.get_master_key().to_vec().into(), + ) + .await + .to_not_found_response(errors::ApiErrorResponse::MerchantAccountNotFound)?; + + let merchant_account = db + .find_merchant_account_by_merchant_id(&merchant_id, &merchant_key_store) + .await + .to_not_found_response(errors::ApiErrorResponse::MerchantAccountNotFound)?; + let is_merchant_account_deleted = db .delete_merchant_account_by_merchant_id(&merchant_id) .await @@ -662,6 +695,14 @@ pub async fn merchant_account_delete( is_deleted = is_merchant_account_deleted && is_merchant_key_store_deleted; } + if let Some(api_key) = merchant_account.publishable_key { + let state = state.clone(); + authentication::decision::spawn_tracked_job( + async move { authentication::decision::revoke_api_key(&state, api_key.into()).await }, + authentication::decision::REVOKE, + ) + } + match db .delete_config_by_key(format!("{}_requires_cvv", merchant_id).as_str()) .await diff --git a/crates/router/src/core/api_keys.rs b/crates/router/src/core/api_keys.rs index 5d41b9c18541..df8a55cd7184 100644 --- a/crates/router/src/core/api_keys.rs +++ b/crates/router/src/core/api_keys.rs @@ -10,7 +10,7 @@ use crate::{ consts, core::errors::{self, RouterResponse, StorageErrorExt}, routes::{metrics, SessionState}, - services::ApplicationResponse, + services::{authentication, ApplicationResponse}, types::{api, storage, transformers::ForeignInto}, utils, }; @@ -148,6 +148,26 @@ pub async fn create_api_key( .change_context(errors::ApiErrorResponse::InternalServerError) .attach_printable("Failed to insert new API key")?; + let state_inner = state.clone(); + let hashed_api_key = api_key.hashed_api_key.clone(); + let merchant_id_inner = merchant_id.clone(); + let key_id = api_key.key_id.clone(); + let expires_at = api_key.expires_at; + + authentication::decision::spawn_tracked_job( + async move { + authentication::decision::add_api_key( + &state_inner, + hashed_api_key.into_inner().into(), + merchant_id_inner, + key_id, + expires_at.map(authentication::decision::convert_expiry), + ) + .await + }, + authentication::decision::ADD, + ); + metrics::API_KEY_CREATED.add( &metrics::CONTEXT, 1, @@ -277,6 +297,25 @@ pub async fn update_api_key( .await .to_not_found_response(errors::ApiErrorResponse::ApiKeyNotFound)?; + let state_inner = state.clone(); + let hashed_api_key = api_key.hashed_api_key.clone(); + let key_id_inner = api_key.key_id.clone(); + let expires_at = api_key.expires_at; + + authentication::decision::spawn_tracked_job( + async move { + authentication::decision::add_api_key( + &state_inner, + hashed_api_key.into_inner().into(), + merchant_id.clone(), + key_id_inner, + expires_at.map(authentication::decision::convert_expiry), + ) + .await + }, + authentication::decision::ADD, + ); + #[cfg(feature = "email")] { let expiry_reminder_days = state.conf.api_keys.get_inner().expiry_reminder_days.clone(); @@ -402,11 +441,30 @@ pub async fn revoke_api_key( key_id: &str, ) -> RouterResponse { let store = state.store.as_ref(); + + let api_key = store + .find_api_key_by_merchant_id_key_id_optional(merchant_id, key_id) + .await + .to_not_found_response(errors::ApiErrorResponse::ApiKeyNotFound)?; + let revoked = store .revoke_api_key(merchant_id, key_id) .await .to_not_found_response(errors::ApiErrorResponse::ApiKeyNotFound)?; + if let Some(api_key) = api_key { + let hashed_api_key = api_key.hashed_api_key; + let state = state.clone(); + + authentication::decision::spawn_tracked_job( + async move { + authentication::decision::revoke_api_key(&state, hashed_api_key.into_inner().into()) + .await + }, + authentication::decision::REVOKE, + ); + } + metrics::API_KEY_REVOKED.add(&metrics::CONTEXT, 1, &[]); #[cfg(feature = "email")] diff --git a/crates/router/src/core/metrics.rs b/crates/router/src/core/metrics.rs index fee3d8aafb37..a2f187eb3ff8 100644 --- a/crates/router/src/core/metrics.rs +++ b/crates/router/src/core/metrics.rs @@ -83,3 +83,6 @@ counter_metric!( ROUTING_RETRIEVE_CONFIG_FOR_PROFILE_SUCCESS_RESPONSE, GLOBAL_METER ); + +counter_metric!(API_KEY_REQUEST_INITIATED, GLOBAL_METER); +counter_metric!(API_KEY_REQUEST_COMPLETED, GLOBAL_METER); diff --git a/crates/router/src/services/authentication.rs b/crates/router/src/services/authentication.rs index adca724e4540..accc7734785c 100644 --- a/crates/router/src/services/authentication.rs +++ b/crates/router/src/services/authentication.rs @@ -40,6 +40,7 @@ use crate::{ }; pub mod blacklist; pub mod cookies; +pub mod decision; #[derive(Clone, Debug)] pub struct AuthenticationData { diff --git a/crates/router/src/services/authentication/decision.rs b/crates/router/src/services/authentication/decision.rs new file mode 100644 index 000000000000..2a48a8c318bc --- /dev/null +++ b/crates/router/src/services/authentication/decision.rs @@ -0,0 +1,217 @@ +use common_utils::{errors::CustomResult, request::RequestContent}; +use masking::{ErasedMaskSerialize, Secret}; +use router_env::opentelemetry::KeyValue; +use serde::Serialize; +use storage_impl::errors::ApiClientError; + +use crate::{ + core::metrics, + routes::{app::settings::DecisionConfig, SessionState}, +}; + +// # Consts +// + +const DECISION_ENDPOINT: &str = "/rule"; +const RULE_ADD_METHOD: common_utils::request::Method = common_utils::request::Method::Post; +const RULE_DELETE_METHOD: common_utils::request::Method = common_utils::request::Method::Delete; + +pub const REVOKE: &str = "REVOKE"; +pub const ADD: &str = "ADD"; + +// # Types +// + +/// [`RuleRequest`] is a request body used to register a new authentication method in the proxy. +#[derive(Debug, Serialize)] +pub struct RuleRequest { + /// [`tag`] similar to a partition key, which can be used by the decision service to tag rules + /// by partitioning identifiers. (e.g. `tenant_id`) + pub tag: String, + /// [`variant`] is the type of authentication method to be registered. + #[serde(flatten)] + pub variant: AuthRuleType, + /// [`expiry`] is the time **in seconds** after which the rule should be removed + pub expiry: Option, +} + +#[derive(Debug, Serialize)] +pub struct RuleDeleteRequest { + pub tag: String, + #[serde(flatten)] + pub variant: AuthType, +} + +#[derive(Debug, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum AuthType { + /// [`ApiKey`] is an authentication method that uses an API key. This is used with [`ApiKey`] + ApiKey { api_key: Secret }, +} + +#[derive(Debug, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum AuthRuleType { + /// [`ApiKey`] is an authentication method that uses an API key. This is used with [`ApiKey`] + /// and [`PublishableKey`] authentication methods. + ApiKey { + api_key: Secret, + identifiers: Identifiers, + }, +} + +#[allow(clippy::enum_variant_names)] +#[derive(Debug, Serialize, Clone)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum Identifiers { + /// [`ApiKey`] is an authentication method that uses an API key. This is used with [`ApiKey`] + ApiKey { merchant_id: String, key_id: String }, + /// [`PublishableKey`] is an authentication method that uses a publishable key. This is used with [`PublishableKey`] + PublishableKey { merchant_id: String }, +} + +// # Decision Service +// + +pub async fn add_api_key( + state: &SessionState, + api_key: Secret, + merchant_id: String, + key_id: String, + expiry: Option, +) -> CustomResult<(), ApiClientError> { + let decision_config = if let Some(config) = &state.conf.decision { + config + } else { + return Ok(()); + }; + + let rule = RuleRequest { + tag: state.tenant.schema.clone(), + expiry, + variant: AuthRuleType::ApiKey { + api_key, + identifiers: Identifiers::ApiKey { + merchant_id, + key_id, + }, + }, + }; + + call_decision_service(state, decision_config, rule, RULE_ADD_METHOD).await +} + +pub async fn add_publishable_key( + state: &SessionState, + api_key: Secret, + merchant_id: String, + expiry: Option, +) -> CustomResult<(), ApiClientError> { + let decision_config = if let Some(config) = &state.conf.decision { + config + } else { + return Ok(()); + }; + + let rule = RuleRequest { + tag: state.tenant.schema.clone(), + expiry, + variant: AuthRuleType::ApiKey { + api_key, + identifiers: Identifiers::PublishableKey { merchant_id }, + }, + }; + + call_decision_service(state, decision_config, rule, RULE_ADD_METHOD).await +} + +async fn call_decision_service( + state: &SessionState, + decision_config: &DecisionConfig, + rule: T, + method: common_utils::request::Method, +) -> CustomResult<(), ApiClientError> { + let mut request = common_utils::request::Request::new( + method, + &(decision_config.base_url.clone() + DECISION_ENDPOINT), + ); + + request.set_body(RequestContent::Json(Box::new(rule))); + request.add_default_headers(); + + let response = state + .api_client + .send_request(state, request, None, false) + .await; + + match response { + Err(error) => { + router_env::error!("Failed while calling the decision service: {:?}", error); + Err(error) + } + Ok(response) => { + router_env::info!("Decision service response: {:?}", response); + Ok(()) + } + } +} + +pub async fn revoke_api_key( + state: &SessionState, + api_key: Secret, +) -> CustomResult<(), ApiClientError> { + let decision_config = if let Some(config) = &state.conf.decision { + config + } else { + return Ok(()); + }; + + let rule = RuleDeleteRequest { + tag: state.tenant.schema.clone(), + variant: AuthType::ApiKey { api_key }, + }; + + call_decision_service(state, decision_config, rule, RULE_DELETE_METHOD).await +} + +/// +/// +/// Safety: i64::MAX < u64::MAX +/// +#[allow(clippy::as_conversions)] +pub fn convert_expiry(expiry: time::PrimitiveDateTime) -> u64 { + let now = common_utils::date_time::now(); + let duration = expiry - now; + let output = duration.whole_seconds(); + + match output { + i64::MIN..=0 => 0, + _ => output as u64, + } +} + +pub fn spawn_tracked_job(future: F, request_type: &'static str) +where + E: std::fmt::Debug, + F: futures::Future> + Send + 'static, +{ + metrics::API_KEY_REQUEST_INITIATED.add( + &metrics::CONTEXT, + 1, + &[KeyValue::new("type", request_type)], + ); + tokio::spawn(async move { + match future.await { + Ok(_) => { + metrics::API_KEY_REQUEST_COMPLETED.add( + &metrics::CONTEXT, + 1, + &[KeyValue::new("type", request_type)], + ); + } + Err(e) => { + router_env::error!("Error in tracked job: {:?}", e); + } + } + }); +}